classdef dotrisLogic < handle
    % @class dotrisLogic
    % Organize logic and behavior for the dotris game.
    % @details
    % Manages things like the game board, pieces, movement of pieces, etc.
    % Does not "know about" any details of how graphics or player input are
    % accomplished.
    % @ingroup demos
    
    properties
        % function to return the time as a number
        clockFunction = @mglGetSecs;
        
        % initial interval between piece falling steps
        tickIntervalDefault = 1;
        
        % current interval between piece fallin steps
        tickInterval;
        
        % clockFunction time when the next piece falling step will happen
        tickNextTime;
        
        % accumulator for left-right sliding input
        slideCounter;
        
        % movement threshold for accumulated for left-right sliding input
        slideThreshold = 60;
        
        % the current dotris game score
        score;
        
        % scale factor from logical to graphical board units
        squareScale = 1;
        
        % height of the game board in logical units
        boardHeight = 24;
        
        % width of the game board in logical units
        boardWidth = 12;
        
        % logical matrix representing the game board
        boardGrid;
        
        % logical matrix representing the piled up game pieces
        pileGrid;
        
        % string names for each type of game piece
        pieceNames = {'J', 'L', 'Z', 'S', 'O', 'I'};
        
        % string name of the type of the current game piece
        pieceType;
        
        % logical matrix representin the current game piece
        pieceGrid;
        
        % logical matrix representing the next game piece
        nextPieceGrid;
        
        % string name of the type of the next game piece
        nextPieceType;
        
        % outputput value indicating that its time for a piece falling step
        outputTickTimeUp;
        
        % outputput value indicating that its not time for a piece falling
        % step
        outputTickOK;
        
        % output value indicating that pieces collided
        outputRatchetLanded;
        
        % output value indicating that pieces did not collide
        outputRatchetOK;
        
        % output value indicating that the game is over
        outputJudgeGameOver;
        
        % output value indicating that the game may continue
        outputJudgeOK;
        
        % fevalable cell array to invoke when a piece moves
        fevalablePieceMoved = {};
        
        % fevalable cell array to invoke for each new piece
        fevalablePieceChanged = {};
        
        % fevalable cell array to invoke when a piece lands on the pile
        fevalablePileChanged = {};
    end
    
    methods
        % Constructor takes no arguments.
        function self = dotrisLogic
            self.startDotris;
        end
        
        % Start a new dotris game.
        function startDotris(self)
            self.tickInterval = self.tickIntervalDefault;
            self.tickNextTime = ...
                feval(self.clockFunction) + self.tickInterval;
            
            self.slideCounter = 0;
            
            bg = false(self.boardHeight, self.boardWidth);
            bg(1,:) = true;
            bg(:,[1,end]) = true;
            self.boardGrid = bg;
            self.pieceGrid = false(self.boardHeight, self.boardWidth);
            self.pileGrid = false(self.boardHeight, self.boardWidth);
            
            self.score = 0;
            
            self.invokeFevalable(self.fevalablePileChanged);
            self.newPiece;
            self.newPiece;
        end
        
        % Make a new piece with a random shape.
        function newPiece(self)
            self.pieceGrid = self.nextPieceGrid;
            self.pieceType = self.nextPieceType;
            
            w = self.boardWidth;
            wMid = floor(w/2);
            h = self.boardHeight;
            row = h;
            piece = false(h,w);
            
            self.nextPieceType = 1 + floor(rand*6);
            switch self.pieceNames{self.nextPieceType}
                case 'J'
                    piece(row, wMid+[-1:1]) = true;
                    piece(row-1, wMid-1) = true;
                case 'L'
                    piece(row, wMid+[-1:1]) = true;
                    piece(row-1, wMid+1) = true;
                case 'Z'
                    piece(row, wMid+[-1:0]) = true;
                    piece(row-1, wMid+[0:1]) = true;
                case 'S'
                    piece(row, wMid+[0:1]) = true;
                    piece(row-1, wMid+[-1:0]) = true;
                case 'O'
                    piece(row, wMid+[0:1]) = true;
                    piece(row-1, wMid+[0:1]) = true;
                case 'I'
                    piece(row, wMid+[-1:2]) = true;
                    
            end
            self.nextPieceGrid = piece;
            self.invokeFevalable(self.fevalablePieceChanged);
        end
        
        % Slide the current piece side-to-side.
        %   - direction <= 0 -> left
        %   - direction > 0 -> right
        function slidePiece(self, direction)
            piece = self.pieceGrid;
            if direction > 0
                piece = piece(:,[end,1:end-1]);
            else
                piece = piece(:,[2:end,1]);
            end
            
            if ~dotrisLogic.gridsAreColliding( ...
                    piece, self.boardGrid, self.pileGrid)
                self.pieceGrid = piece;
                self.invokeFevalable(self.fevalablePieceMoved);
            end
        end
        
        % Accumulate input towards sliding the current piece.
        function incrementSlideCounter(self, increment)
            count = self.slideCounter;
            
            % canel acumulation in the opposite direction
            if sign(count) ~= sign(increment)
                count = 0;
            end
            
            % start with a burst
            doSlide = count == 0;
            
            % accumulate towards a repeat
            count = count + increment;
            
            % trip over the threshold
            if abs(count) >= self.slideThreshold
                doSlide = true;
                count = 0;
            end
            
            self.slideCounter = count;
            
            if doSlide
                self.slidePiece(increment)
            end
        end
        
        % Spin the current piece in place.
        %   - direction <= 0 -> clockwise
        %   - direction > 0 -> counterclockwise
        function spinPiece(self, direction)
            piece = self.pieceGrid;
            
            % spin about the rounded geometric mean
            [row,col,sz] = dotrisLogic.gridSubscripts(piece);
            rowMean = round(prod(row).^(1/4));
            colMean = round(prod(col).^(1/4));
            
            piece(piece) = false;
            if direction > 0
                % counterclockwise
                spunRow = colMean-col + rowMean;
                spunCol = row-rowMean + colMean;
                
            else
                % clockwise
                spunRow = col-colMean + rowMean;
                spunCol = rowMean-row + colMean;
                
            end
            
            if all(spunRow > 0) && all(spunRow <= sz(1)) ...
                    && all(spunCol > 0) && all(spunCol <= sz(2))
                
                indexes = dotrisLogic.gridIndexes(spunRow, spunCol, sz);
                piece(indexes) = true;
                
                if ~dotrisLogic.gridsAreColliding( ...
                        piece, self.boardGrid, self.pileGrid)
                    self.pieceGrid = piece;
                    self.invokeFevalable(self.fevalablePieceMoved);
                end
            end
        end
        
        % Move the current piece down as far as it will go, immediately.
        function dropPiece(self)
            piece = self.pieceGrid;
            previousPiece = self.pieceGrid;
            while ~dotrisLogic.gridsAreColliding( ...
                    piece, self.boardGrid, self.pileGrid)
                previousPiece = piece;
                piece = piece([2:end,1],:);
            end
            self.pieceGrid = previousPiece;
            self.invokeFevalable(self.fevalablePieceMoved);
        end
        
        % Transfer the current piece to the pile.
        function landPiece(self)
            piece = self.pieceGrid;
            self.pileGrid(piece) = true;
            self.pieceGrid(piece) = false;
            self.invokeFevalable(self.fevalablePieceMoved);
            self.invokeFevalable(self.fevalablePileChanged);
        end
        
        % Tick the timer piece falling timer, report and reset it when time
        % is up.
        function output = tick(self)
            nowTime = feval(self.clockFunction);
            if nowTime >= self.tickNextTime
                self.tickNextTime = nowTime + self.tickInterval;
                output = self.outputTickTimeUp;
            else
                output = self.outputTickOK;
            end
        end
        
        % Drop the current piece one row, report when it bumps the pile or
        % board.
        function output = ratchet(self)
            % move the piece down a step
            piece = self.pieceGrid;
            piece = piece([2:end,1],:);
            
            % check for collisions
            if dotrisLogic.gridsAreColliding( ...
                    piece, self.boardGrid, self.pileGrid)
                output = self.outputRatchetLanded;
                
            else
                output = self.outputRatchetOK;
                self.pieceGrid = piece;
                self.invokeFevalable(self.fevalablePieceMoved);
            end
        end
        
        % Detect full lines, clear and score them, and judge for game over.
        function output = judge(self)
            % look for lines
            pile = self.pileGrid;
            rowIsLine = all(pile|self.boardGrid,2);
            rowIsLine(1) = false;
            nLines = sum(rowIsLine);
            self.score = self.score + nLines;
            
            % extract lines from pile
            pile(1:(end-nLines),:) = pile(~rowIsLine,:);
            
            % look for filled up board
            if any(pile(end,:))
                output = self.outputJudgeGameOver;
            else
                output = self.outputJudgeOK;
            end
            self.pileGrid = pile;
            self.invokeFevalable(self.fevalablePileChanged);
        end
        
        % Convert a true/false grid to centered, scaled x-y coordinates.
        %   - row -> x
        %   - column -> y
        function [x,y] = gridToScaledXY(self, grid)
            [row,col,sz] = dotrisLogic.gridSubscripts(grid);
            x = self.squareScale*(col - sz(2)/2);
            y = self.squareScale*(row - sz(1)/2);
        end
        
        % Invoke a callback function.
        function invokeFevalable(self, fevalable)
            if ~isempty(fevalable)
                feval(fevalable{1}, self, fevalable{2:end});
            end
        end
    end
    
    methods (Static)
        % Check whether given grids have colliding elements.
        function isColliding = gridsAreColliding(varargin)
            allGrids = cat(3, varargin{:});
            sumGrids = sum(allGrids, 3);
            isColliding = any(sumGrids(1:end) > 1);
        end
        
        % Convert a true/false grid to 1-based row and column subscripts.
        function [row,col,sz] = gridSubscripts(grid)
            sz = size(grid);
            indexes = find(grid(1:end))-1;
            col = 1 + floor(indexes/sz(1));
            row = 1 + mod(indexes, sz(1));
        end
        
        % Convert 1-based row and column subscripts to linear indexes.
        function indexes = gridIndexes(row, col, sz)
            indexes = row + (col-1)*sz(1);
        end
    end
end